<?php

namespace Qm;

class FileParser {

    /** @var bool code-reading flag */
    private $bOpenFamily  = FALSE;

    /** @var bool code-reading flag */
    private $bOpenQuery   = FALSE;

    /** @var bool code-reading flag */
    private $bOpenComment = FALSE;

    /** @var bool code-reading flag */
    private $bNoParse     = FALSE;

    /** @var object actual open family */
    private $oFamily      = NULL;

    /** @var object actual open query */
    private $oQuery       = NULL;

    /** @var object content of given file */
    private $oFileContent = NULL;

    /** @var string family declaration */
    private $sFamilyHead  = NULL;

    /** @var string query declaration */
    private $sQueryHead   = NULL;

    /** @var integer number of opened curly brackets */
    private $iOpenBracket = 0;

    /** @var integer number of line which is being processed */
    private $iLine        = 0;

    /** @var string template source file */
    private $sFileName    = NULL;

    /** @todo remove */
    public function dump() { var_dump($this->oFileContent); }

    /**
     * Get parsed file content.
     * @return \Qm\FileContent
     * @throws LogicException if file has not been parsed
     */
    public function getParsedFile() {

        if( is_null( $this->oFileContent ) ) {
            throw new LogicException( 'File has not been parsed yet.' );
        } #if

        return $this->oFileContent;
    }

    /**
     * Source file setter.
     * @param   string $sFileName path to the file
     */
    public function setFile( $sFileName ) {

        if( ! is_readable( $sFileName ) ) {
            throw new RuntimeException( "Template source file '$sFileName' does not exist or is not readable." );
        } #if

        $this->sFileName = $sFileName;
    } #function setFile

    /**
     * Start file processing.
     */
    public function parseFile() {

        if( is_null( $this->sFileName ) ) {
            throw new LogicException( 'Template source file has not been set yet.' );
        } #if

        $aSource = file( $this->sFileName, FILE_IGNORE_NEW_LINES );

        if( FALSE === $aSource ) {
            throw new RuntimeException( "File '{$this->sFileName }' reading failure." );
        } #if

        $this->oFileContent = new \Qm\FileContent( $this->sFileName );

        foreach( $aSource as $iLine => $sLine ) {
            $this->parseLine( $sLine, $iLine + 1 );
        } #foreach

        $this->parsingTermination();
    } #function processFile

    /**
     * Parse one raw line from source file.
     * @param   string $sLine source
     * @param   int $iLine line number
     */
    private function parseLine( $sLine, $iLine ) {

        $this->iLine = $iLine;
        $sLine = trim( $sLine );
        $sLine = $this->stripComments( $sLine );

        if( empty ( $sLine ) ) {
            // there's nothing to do if given line is empty
            return;
        } #if

        // we are looking for openings and closures for families and queries
        $sAtomPattern = '/(\{|\})/';
        $aAtom = preg_split(  $sAtomPattern, $sLine , -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );

        foreach( $aAtom as $i => $sAtom ) {

            // count number of open curly brackets
            switch( $sAtom ) {
                case '{' :
                    $this->iOpenBracket++;
                    break;
                case '}' :
                    $this->iOpenBracket--;
                    break;
            } #switch

            // if number of open curly brackets is 1 or 0 then founded bracket
            // closing has to be closure for family or query
            if ( $sAtom == '}' && $this->iOpenBracket < 2 ) {
                switch( $this->iOpenBracket ) {
                    case 1:
                        if( ! $this->bOpenQuery ) {
                            throw new SyntaxException(
                                'Unexcepted query closure (symbol "}").',
                                $this->sFileName,
                                $this->iLine );
                        } #if
                        $this->bOpenQuery = FALSE;
                        continue( 2 );
                    case 0:
                        if( ! $this->bOpenFamily ) {
                            throw new SyntaxException(
                                'Unexcepted family closure (symbol "}").',
                                $this->sFileName,
                                $this->iLine );
                        } #if
                        $this->bOpenFamily = FALSE;
                        continue( 2 );
                    default:
                        throw new SyntaxException(
                            'Unexcepted closure "}".',
                            $this->sFileName,
                            $this->iLine );
                } #switch
            } #if

            // no family is open = we are parsing family declaration
            if( ! $this->bOpenFamily ) {
                if( $this->iOpenBracket == 1 ) {
                    $this->parseFamilyDeclaration();
                } else {
                    $this->colaseFamilyDeclaration( $sAtom );
                } #if

            // family is open, but no query is open = we are parsing
            // query declaration
            } elseif( ! $this->bOpenQuery ) {
                if( $this->iOpenBracket == 2 ) {
                    $this->parseQueryDeclaration();
                } else {
                    $this->colaseQueryDeclaration( $sAtom );
                } #if

            // query is open = given line is just part of actual query
            } else {
                $this->oQuery->pushLine( $sAtom, $this->iLine );
            } #if
        } #if
    } #function parseLine

    /**
     * Glue family declaration
     * @param   string $sCode part of family declaration source
     */
    private function colaseFamilyDeclaration( $sCode ) {

        $this->sFamilyHead .= ' ' . $sCode;
    } #function colaseFamilyDeclaration

    /**
     * Process glued family declaration
     * @uses   $this->sFamilyHead
     */
    private function parseFamilyDeclaration() {

        // family head pattern
        $sPattern   = '/^\s*family\s+(?<name>\w+)(\s+extends\s+(?<extends>\w+))?\s*$/i';
        $sSource    = $this->sFamilyHead;

        if( ! preg_match( $sPattern, $sSource, $aMatches ) ) {
            throw new SyntaxException(
                'Syntax error when family declaration is expected.',
                $this->sFileName,
                $this->iLine );
        } #if

        $sName      = $aMatches['name'];
        $sExtends   = isset( $aMatches['extends'] ) ? $aMatches['extends'] : NULL;

        // check if name is unique in given file
        foreach( $this->oFileContent->aFamily as $oFamily ) {
            if( $sName == $oFamily->sName) {
                throw new NamingException(
                    "Family name '{$sName}' is not unique in given file.",
                    $this->sFileName,
                    $this->iLine );
            } #if
        } #if

        $this->oFileContent->aFamily[] = $this->oFamily = new \Qm\Family( $sName, $this->sFileName, $sExtends );
        $this->bOpenFamily = TRUE;
        $this->sFamilyHead = NULL;

    } #function parseFamilyDeclaration

    /**
     * Glue query declaration
     * @param   string $sCode part of query declaration source
     */
    private function colaseQueryDeclaration( $sCode ) {

        $this->sQueryHead .= ' ' . $sCode;
    } #function colaseQueryDeclaration

    /**
     * Process glued query declaration
     * @uses    $this->sQueryHead
     */
    private function parseQueryDeclaration() {

        // query head pattern
        $sSource        = $this->sQueryHead;
        $sHeadPattern   =
            '/^\s*
                (?:
                    (?<access> (public|private|protected) )\s+
                )?
                query \s+(?<name>\w+) \s*\(
                    (?<args>.*)
                \)\s*$
            /iSx';

        if( ! preg_match( $sHeadPattern, $sSource, $aMatches ) ) {
            throw new SyntaxException(
                'Syntax error when query declaration is expected.',
                $this->sFileName,
                $this->iLine );
        } #if

        $sName      = $aMatches['name'];
        $sAccess    = empty( $aMatches['access'] ) ? 'public' : strtolower( $aMatches['access'] );
        $sArgument  = $aMatches['args'];
        $aArgument  = $this->parseArguments( $sArgument );

        foreach( $this->oFamily->aQuery as $oQuery ) {
            if( $oQuery->sName == $sName ) {
                throw new NamingException(
                    "Query name '{$sName}' is not unique in family '{$oFamily->sName}'.",
                    $this->sFileName,
                    $this->iLine );
            } #if
        } #if

        $this->oFamily->aQuery[] =  $this->oQuery = new \Qm\Query( $sName, $sAccess, $aArgument );
        $this->bOpenQuery = TRUE;
        $this->sQueryHead = NULL;
    } #function parseQueryDeclaration

    /**
     * Process parsed argument from query declaration
     * @param   string $sArgument raw parsed argument
     * @return  array arguments in format variable_name => value
     */
    public function parseArguments( $sArgument ) {

        $sArgument = trim( $sArgument );
        $aArgument = array();

        if( empty ( $sArgument ) ) {
            return( $aArgument );
        } #if

        // hell hell hell hell
        $sArgsPattern =
            '/\s*
                (?<name>\$\w+)
                (?:\s*\=\s*
                    (?<value>
                        (?<array>
                            (?<primitive>
                                null |
                                false |
                                true |
                                (?:[+-]?0x[0-9a-fA-F]+) |
                                [+-]?\d+(?:\.\d+)? |
                                "[^"\\\\]*(?:\\\\.[^"\\\\]*)*" |
                                \'[^\'\\\\]*(?:\\\\.[^\'\\\\]*)*\'
                            ) |
                            array\s*\(\s*
                                (?:
                                    (?<item>
                                        (?:(?&primitive)\s*\=\>\s*)?
                                        (?:(?&primitive) | (?&array))
                                    )(?:
                                        \s*,\s*
                                        (?&item)
                                    )*
                                )?
                            \s*\)
                        )
                    )
                )?\s*,
            /iSx';

        // callback function
        $cArgsParsing = function( $aMatch ) use( &$aArgument ) {
            if( array_key_exists( $aMatch['name'], $aArgument ) ) {
                throw new NamingException(
                    "Argument '{$aMatch[ 'name']}' is not unique.",
                    $this->sFileName,
                    $this->iLine );
            } #if
            // matched argument and it's default value is stored
            $aArgument[$aMatch[ 'name']] = isset( $aMatch['value'] ) ? $aMatch['value'] : NULL;
            // and erased from string to search
            return( '' );
        };

        // this hack helps to simplify regexp above
        $sArgument .= ',';
        $sRest = preg_replace_callback( $sArgsPattern, $cArgsParsing, $sArgument );

        // all matched substrings were deleted so in $sRest should be only empty string
        if( ! empty( $sRest ) ) {
            throw new SyntaxException(
                'Syntax error in argument the part of query declaration.',
                $this->sFileName,
                $this->iLine );
        } #if
        return( $aArgument );
    } #function parseArguments

    /**
     * Remove comments from given line.
     * @param   type $sLine raw line
     * @return  string given line without comments
     * @uses    $this->bOpenComment
     */
    private function stripComments( $sLine ) {

        // list of php-style single-line comment patterns...
        $aCommentPattern = array(
            // comment
            '/^(.*)(\/\/.*)$/',
             # comment
            '/^([^#]*)(#.*)$/',
            /* comment */
            '/^(.*)\/\*(.*)\*\/(.*)$/' );

        // ...and their replacements
        $aCommentReplacement = array(
            '$1',
            '$1',
            '$1$3' );

        // remove single-line comments from a given line
        $sLine = preg_replace( $aCommentPattern, $aCommentReplacement, $sLine );

        // remove multi-line comment
        if( !$this->bOpenComment && is_int( $iCommentStart = strpos( $sLine, '/*' ) ) ) {
            $sLine = substr( $sLine, 0, $iCommentStart );
            $this->bOpenComment = TRUE;
        } elseif( $this->bOpenComment ) {
            if( is_int( $iCommentEnd = strpos( $sLine, '*/' ) ) ) {
                $sLine = substr( $sLine, $iCommentEnd + 2 );
                $this->bOpenComment = FALSE;
            } else {
                // line is part of multi-line comment
                $sLine = NULL;
            } #if
        } #if

        return $sLine;
    } #function stripComments

    /**
     * Terminate file parsing process.
     */
    private function parsingTermination() {

        if( $this->bOpenComment ) {
            throw new SyntaxException( 'Unexpected file end; unclosed comment.', $this->sFileName, $this->iLine );
        }

        if( $this->bOpenQuery ) {
            throw new SyntaxException( 'Unexpected file end; unclosed query.', $this->sFileName, $this->iLine );
        }

        if( $this->bOpenFamily ) {
            throw new SyntaxException( 'Unexpected file end; unclosed family.', $this->sFileName, $this->iLine );
        }
    }
} #class FileParser